Skip to content

쿠폰, 알림 수정 - #109

Merged
jaebeom79 merged 3 commits into
mainfrom
develop
May 30, 2026
Merged

쿠폰, 알림 수정#109
jaebeom79 merged 3 commits into
mainfrom
develop

Conversation

@jaebeom79

Copy link
Copy Markdown
Contributor

📢 기능 설명

  • 쿠폰 발급 시 재고 수정 안되는 문제 해결
  • 예약 관련 알림 안 오는 문제 해결

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request shifts the source of truth for coupon stock from the database to Redis, introducing a background scheduler (CouponStockSyncScheduler) to periodically sync Redis stock back to the database, and updating CouponService to resolve stock dynamically. Additionally, it refactors KafkaConfig to resolve partition assignment issues by removing a conflicting error handler. The review feedback highlights critical performance and concurrency issues: holding database transactions open during Redis network I/O in both the scheduler and CouponService can lead to connection pool starvation, and the scheduler lacks distributed locking for multi-instance environments. Addressing these by narrowing transaction scopes and applying distributed locks is highly recommended.

Comment on lines +34 to +52
private final CouponTemplateRepository couponTemplateRepository;
private final RedisCouponIssuer redisCouponIssuer;

@Scheduled(fixedDelay = SYNC_INTERVAL_MS)
@Transactional
public void syncStock() {
List<CouponTemplate> templates = couponTemplateRepository.findActiveTemplates(LocalDateTime.now());
for (CouponTemplate template : templates) {
try {
Integer stock = redisCouponIssuer.getStock(template.getId());
if (stock == null) continue;
if (!stock.equals(template.getRemain())) {
template.syncRemain(stock);
}
} catch (Exception e) {
log.warn("쿠폰 stock sync 실패. templateId={}", template.getId(), e);
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

이슈 및 개선 포인트:
본 스케줄러는 실서비스 환경(다중 인스턴스/멀티 포드)에서 운영될 때 두 가지 중요한 잠재적 문제를 가지고 있습니다.

  1. DB 커넥션 풀 고갈 위험 (Connection Pool Starvation):
    @TransactionalsyncStock() 메서드 전체에 걸려 있어, 루프 내부에서 Redis 네트워크 I/O(redisCouponIssuer.getStock())를 호출하는 동안 DB 커넥션을 계속 점유하게 됩니다. Redis 지연이 발생하면 HikariCP 커넥션 풀이 빠르게 고갈될 수 있습니다.
  2. 다중 인스턴스 중복 실행 (Redundant Execution):
    스케줄러가 각 인스턴스마다 5초 주기로 동시에 실행되어 불필요한 DB 조회 및 업데이트 경쟁(Lock Contention)을 유발합니다.

해결 방안:

  • @Transactional을 메서드 레벨에서 제거하고, 실제 DB 업데이트가 필요한 시점에만 TransactionTemplate을 사용하여 트랜잭션 범위를 최소화합니다.
  • 이미 프로젝트에 도입되어 있는 RedissonClient를 활용하여 분산 락(Distributed Lock)을 적용함으로써, 다중 서버 환경에서도 단 하나의 인스턴스만 동기화 작업을 수행하도록 보장합니다.
    private final CouponTemplateRepository couponTemplateRepository;
    private final RedisCouponIssuer redisCouponIssuer;
    private final org.redisson.api.RedissonClient redissonClient;
    private final org.springframework.transaction.support.TransactionTemplate transactionTemplate;

    @Scheduled(fixedDelay = SYNC_INTERVAL_MS)
    public void syncStock() {
        var lock = redissonClient.getLock("lock:coupon:sync");
        try {
            if (lock.tryLock(0, 4, java.util.concurrent.TimeUnit.SECONDS)) {
                try {
                    List<CouponTemplate> templates = couponTemplateRepository.findActiveTemplates(LocalDateTime.now());
                    for (CouponTemplate template : templates) {
                        try {
                            Integer stock = redisCouponIssuer.getStock(template.getId());
                            if (stock == null) continue;
                            if (!stock.equals(template.getRemain())) {
                                transactionTemplate.executeWithoutResult(status -> {
                                    couponTemplateRepository.findById(template.getId())
                                            .ifPresent(t -> t.syncRemain(stock));
                                });
                            }
                        } catch (Exception e) {
                            log.warn("쿠폰 stock sync 실패. templateId={}", template.getId(), e);
                        }
                    }
                } finally {
                    if (lock.isHeldByCurrentThread()) {
                        lock.unlock();
                    }
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("쿠폰 stock sync 락 획득 중 인터럽트 발생", e);
        }
    }

Comment on lines 112 to 118
@Transactional(readOnly = true)
public List<CouponTemplateActiveResponse> getActiveTemplates() {
return couponTemplateRepository.findActiveTemplates(LocalDateTime.now()).stream()
.map(CouponTemplateActiveResponse::from)
.map(template -> CouponTemplateActiveResponse.from(template, resolveStock(template)))
.filter(response -> response.remain() != null && response.remain() > 0)
.toList();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

이슈: getActiveTemplates() 메서드에 @Transactional(readOnly = true)가 적용되어 있어, DB 조회 후 Stream을 통해 각 템플릿마다 Redis 호출(resolveStock)을 수행하는 동안 DB 커넥션을 계속 점유하게 됩니다. 템플릿 개수가 많아지거나 Redis 지연이 발생할 경우 커넥션 풀 고갈로 이어질 수 있습니다.

해결 방안: CouponTemplate 엔티티에서 지연 로딩(Lazy Loading)을 사용하는 연관 관계를 조회하지 않으므로, @Transactional(readOnly = true) 어노테이션을 안전하게 제거할 수 있습니다. 이를 통해 DB 조회 후 커넥션을 즉시 반환하고, Redis 조회는 트랜잭션 외부에서 안전하게 수행되도록 개선할 수 있습니다.

    public List<CouponTemplateActiveResponse> getActiveTemplates() {
        return couponTemplateRepository.findActiveTemplates(LocalDateTime.now()).stream()
                .map(template -> CouponTemplateActiveResponse.from(template, resolveStock(template)))
                .filter(response -> response.remain() != null && response.remain() > 0)
                .toList();
    }

@jaebeom79
jaebeom79 merged commit 2c2187a into main May 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant